写真フィルターカルーセルを作成する
フィルターを使用すると写真がより良く見えることは誰もが知っています。 このレシピでは、スクロール可能なオブジェクトを構築します。 フィルター選択カルーセル。
次のアニメーションはアプリの動作を示しています。
このレシピは写真とフィルターから始まります
すでに設置されています。フィルターはcolor
とcolorBlendMode
のプロパティImage
ウィジェット。
セレクターリングとダークグラデーションを追加
選択したフィルター円が枠内に表示されます。 セレクターリング。さらに、暗いグラデーションは、 利用可能なフィルターの背後にあるため、コントラストが向上します フィルターと選択した写真の間。
という名前の新しいステートフル ウィジェットを作成します。FilterSelector
使い慣れたもの
セレクターを実装します。
@immutable
class FilterSelector extends StatefulWidget {
const FilterSelector({
super.key,
});
@override
State<FilterSelector> createState() => _FilterSelectorState();
}
class _FilterSelectorState extends State<FilterSelector> {
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
を追加します。FilterSelector
ウィジェットを既存の
ウィジェットツリー。位置を決めますFilterSelector
ウィジェット
写真の上、下、中央にあります。
Stack(
children: [
Positioned.fill(
child: _buildPhotoWithFilter(),
),
const Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
child: FilterSelector(),
),
],
),
以内FilterSelector
ウィジェット、
セレクターリングを上に表示する
を使用した暗いグラデーションStack
ウィジェット。
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildShadowGradient(itemSize),
_buildSelectionRing(itemSize),
],
);
},
);
}
Widget _buildShadowGradient(double itemSize) {
return SizedBox(
height: itemSize * 2 + widget.padding.vertical,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: SizedBox.expand(),
),
);
}
Widget _buildSelectionRing(double itemSize) {
return IgnorePointer(
child: Padding(
padding: widget.padding,
child: SizedBox(
width: itemSize,
height: itemSize,
child: const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(width: 6, color: Colors.white),
),
),
),
),
),
);
}
}
セレクターサークルのサイズと背景のグラデーション
カルーセル内の個々のフィルターのサイズに依存します
呼ばれたitemSize
。のitemSize
利用可能な幅によって異なります。
したがって、LayoutBuilder
ウィジェットは、
利用可能なスペースを調べてから、
個々のフィルターitemSize
。
セレクターリングには、IgnorePointer
ウィジェット
カルーセルのインタラクティブ性が追加されると、
セレクターリングが干渉しないはずです
イベントをタップしてドラッグします。
フィルターカルーセルアイテムを作成する
カルーセル内の各フィルター項目には、 画像に色が適用された円形の画像 これは、関連するフィルターの色に対応します。
という新しいステートレス ウィジェットを定義します。FilterItem
単一のリスト項目を表示します。
フィルターカルーセルを実装する
ユーザーがドラッグすると、フィルター項目が左右にスクロールします。スクロールには必要なものがあります
ある種のScrollable
ウィジェット。
水平方向の使用を検討することもできますListView
ウィジェット、
しかし、ListView
ウィジェットは最初の要素を次の位置に配置します。
使用可能なスペースの先頭ではなく、
セレクターリングが位置する中央。
あPageView
ウィジェットはカルーセルに適しています。
あPageView
ウィジェットはその子を
利用可能なスペースの中心に配置し、スナップ物理を提供します。
スナップ物理はアイテムを中心にスナップさせるものです。
ユーザーがどこでドラッグを放しても。
ウィジェット ツリーを構成して、PageView
。
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildShadowGradient(itemSize),
_buildCarousel(itemSize),
_buildSelectionRing(itemSize),
],
);
});
}
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return const SizedBox();
},
),
);
}
それぞれを構築するFilterItem
内のウィジェットPageView
指定されたウィジェットに基づくindex
。
Color itemColor(int index) => widget.filters[index % widget.filters.length];
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: FilterItem(
color: itemColor(index),
onFilterSelected: () {},
),
);
},
),
);
}
のPageView
ウィジェットにはすべてが表示されますFilterItem
ウィジェットを左右にドラッグできます。
ただ、今のところはそれぞれFilterItem
ウィジェットはかかります
画面の幅全体に、
そしてそれぞれFilterItem
ウィジェットが表示される
同じサイズと不透明度で。あるはずです
五FilterItem
画面上のウィジェット、
そしてそのFilterItem
ウィジェットは縮小する必要があり、
画面の中心から遠ざかるにつれてフェードします。
これら両方の問題の解決策は、
あるPageViewController
。のPageViewController
のviewportFraction
プロパティは表示に使用されます
多数FilterItem
画面上のウィジェット
同じ時間です。それぞれを再構築するFilterItem
ウィジェット
としてPageViewController
変更により、次のことが可能になります
それぞれ変更しますFilterItem
ウィジェットのサイズと不透明度
ユーザーがスクロールすると。
を作成しますPageViewController
に接続しますPageView
ウィジェット。
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;
late final PageController _controller;
Color itemColor(int index) => widget.filters[index % widget.filters.length];
@override
void initState() {
super.initState();
_controller = PageController(
viewportFraction: _viewportFractionPerItem,
);
_controller.addListener(_onPageChanged);
}
void _onPageChanged() {
final page = (_controller.page ?? 0).round();
widget.onFilterChanged(widget.filters[page]);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: FilterItem(
color: itemColor(index),
onFilterSelected: () {},
),
);
},
),
);
}
}
とともにPageViewController
追加、5FilterItem
ウィジェットは同時に画面上に表示されます。
スクロールすると写真のフィルターが変わりますが、
のFilterItem
ウィジェットのサイズは変わりません。
それぞれを包みますFilterItem
ウィジェット付きAnimatedBuilder
それぞれの視覚的なプロパティを変更するにはFilterItem
ウィジェットのスクロール位置が変化します。
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FilterItem(
color: itemColor(index),
onFilterSelected: () => {},
);
},
),
);
},
),
);
}
のAnimatedBuilder
ウィジェットは毎回再構築されます_controller
スクロール位置が変わります。
これらの再構築により、FilterItem
ユーザーがドラッグするとサイズと不透明度が変わります。
それぞれに適切なスケールと不透明度を計算します。FilterItem
内のウィジェットAnimatedBuilder
と
それらの値を適用します。
Widget _buildCarousel(double itemSize) {
return Container(
height: itemSize,
margin: widget.padding,
child: PageView.builder(
controller: _controller,
itemCount: widget.filters.length,
itemBuilder: (context, index) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
if (!_controller.hasClients ||
!_controller.position.hasContentDimensions) {
// The PageViewController isn’t connected to the
// PageView widget yet. Return an empty box.
return const SizedBox();
}
// The integer index of the current page,
// 0, 1, 2, 3, and so on
final selectedIndex = _controller.page!.roundToDouble();
// The fractional amount that the current filter
// is dragged to the left or right, for example, 0.25 when
// the current filter is dragged 25% to the left.
final pageScrollAmount = _controller.page! - selectedIndex;
// The page-distance of a filter just before it
// moves off-screen.
const maxScrollDistance = _filtersPerScreen / 2;
// The page-distance of this filter item from the
// currently selected filter item.
final pageDistanceFromSelected =
(selectedIndex - index + pageScrollAmount).abs();
// The distance of this filter item from the
// center of the carousel as a percentage, that is, where the selector
// ring sits.
final percentFromCenter =
1.0 - pageDistanceFromSelected / maxScrollDistance;
final itemScale = 0.5 + (percentFromCenter * 0.5);
final opacity = 0.25 + (percentFromCenter * 0.75);
return Transform.scale(
scale: itemScale,
child: Opacity(
opacity: opacity,
child: FilterItem(
color: itemColor(index),
onFilterSelected: () => () {},
),
),
);
},
),
);
},
),
);
}
各FilterItem
ウィジェットが縮小してフェードするようになりました
画面の中心から遠ざかるにつれて遠ざかります。
選択したフィルターを変更するメソッドを追加します。FilterItem
ウィジェットがタップされた状態です。
void _onFilterTapped(int index) {
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 450),
curve: Curves.ease,
);
}
それぞれを設定しますFilterItem
呼び出すウィジェット_onFilterTapped
タップすると。
FilterItem(
color: itemColor(index),
onFilterSelected: () => _onFilterTapped,
),
おめでとう! これで、ドラッグおよびタップ可能な写真フィルター カルーセルが完成しました。
インタラクティブな例
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ViewportOffset;
void main() {
runApp(
const MaterialApp(
home: ExampleInstagramFilterSelection(),
debugShowCheckedModeBanner: false,
),
);
}
@immutable
class ExampleInstagramFilterSelection extends StatefulWidget {
const ExampleInstagramFilterSelection({super.key});
@override
State<ExampleInstagramFilterSelection> createState() =>
_ExampleInstagramFilterSelectionState();
}
class _ExampleInstagramFilterSelectionState
extends State<ExampleInstagramFilterSelection> {
final _filters = [
Colors.white,
...List.generate(
Colors.primaries.length,
(index) => Colors.primaries[(index * 4) % Colors.primaries.length],
)
];
final _filterColor = ValueNotifier<Color>(Colors.white);
void _onFilterChanged(Color value) {
_filterColor.value = value;
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: _buildPhotoWithFilter(),
),
Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
child: _buildFilterSelector(),
),
],
),
);
}
Widget _buildPhotoWithFilter() {
return ValueListenableBuilder(
valueListenable: _filterColor,
builder: (context, color, child) {
return Image.network(
'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millenial-dude.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.color,
fit: BoxFit.cover,
);
},
);
}
Widget _buildFilterSelector() {
return FilterSelector(
onFilterChanged: _onFilterChanged,
filters: _filters,
);
}
}
@immutable
class FilterSelector extends StatefulWidget {
const FilterSelector({
super.key,
required this.filters,
required this.onFilterChanged,
this.padding = const EdgeInsets.symmetric(vertical: 24),
});
final List<Color> filters;
final void Function(Color selectedColor) onFilterChanged;
final EdgeInsets padding;
@override
State<FilterSelector> createState() => _FilterSelectorState();
}
class _FilterSelectorState extends State<FilterSelector> {
static const _filtersPerScreen = 5;
static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;
late final PageController _controller;
late int _page;
int get filterCount => widget.filters.length;
Color itemColor(int index) => widget.filters[index % filterCount];
@override
void initState() {
super.initState();
_page = 0;
_controller = PageController(
initialPage: _page,
viewportFraction: _viewportFractionPerItem,
);
_controller.addListener(_onPageChanged);
}
void _onPageChanged() {
final page = (_controller.page ?? 0).round();
if (page != _page) {
_page = page;
widget.onFilterChanged(widget.filters[page]);
}
}
void _onFilterTapped(int index) {
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 450),
curve: Curves.ease,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scrollable(
controller: _controller,
axisDirection: AxisDirection.right,
physics: const PageScrollPhysics(),
viewportBuilder: (context, viewportOffset) {
return LayoutBuilder(
builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
viewportOffset
..applyViewportDimension(constraints.maxWidth)
..applyContentDimensions(0.0, itemSize * (filterCount - 1));
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildShadowGradient(itemSize),
_buildCarousel(
viewportOffset: viewportOffset,
itemSize: itemSize,
),
_buildSelectionRing(itemSize),
],
);
},
);
},
);
}
Widget _buildShadowGradient(double itemSize) {
return SizedBox(
height: itemSize * 2 + widget.padding.vertical,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: SizedBox.expand(),
),
);
}
Widget _buildCarousel({
required ViewportOffset viewportOffset,
required double itemSize,
}) {
return Container(
height: itemSize,
margin: widget.padding,
child: Flow(
delegate: CarouselFlowDelegate(
viewportOffset: viewportOffset,
filtersPerScreen: _filtersPerScreen,
),
children: [
for (int i = 0; i < filterCount; i++)
FilterItem(
onFilterSelected: () => _onFilterTapped(i),
color: itemColor(i),
),
],
),
);
}
Widget _buildSelectionRing(double itemSize) {
return IgnorePointer(
child: Padding(
padding: widget.padding,
child: SizedBox(
width: itemSize,
height: itemSize,
child: const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(width: 6, color: Colors.white),
),
),
),
),
),
);
}
}
class CarouselFlowDelegate extends FlowDelegate {
CarouselFlowDelegate({
required this.viewportOffset,
required this.filtersPerScreen,
}) : super(repaint: viewportOffset);
final ViewportOffset viewportOffset;
final int filtersPerScreen;
@override
void paintChildren(FlowPaintingContext context) {
final count = context.childCount;
// All available painting width
final size = context.size.width;
// The distance that a single item "page" takes up from the perspective
// of the scroll paging system. We also use this size for the width and
// height of a single item.
final itemExtent = size / filtersPerScreen;
// The current scroll position expressed as an item fraction, e.g., 0.0,
// or 1.0, or 1.3, or 2.9, etc. A value of 1.3 indicates that item at
// index 1 is active, and the user has scrolled 30% towards the item at
// index 2.
final active = viewportOffset.pixels / itemExtent;
// Index of the first item we need to paint at this moment.
// At most, we paint 3 items to the left of the active item.
final min = math.max(0, active.floor() - 3).toInt();
// Index of the last item we need to paint at this moment.
// At most, we paint 3 items to the right of the active item.
final max = math.min(count - 1, active.ceil() + 3).toInt();
// Generate transforms for the visible items and sort by distance.
for (var index = min; index <= max; index++) {
final itemXFromCenter = itemExtent * index - viewportOffset.pixels;
final percentFromCenter = 1.0 - (itemXFromCenter / (size / 2)).abs();
final itemScale = 0.5 + (percentFromCenter * 0.5);
final opacity = 0.25 + (percentFromCenter * 0.75);
final itemTransform = Matrix4.identity()
..translate((size - itemExtent) / 2)
..translate(itemXFromCenter)
..translate(itemExtent / 2, itemExtent / 2)
..multiply(Matrix4.diagonal3Values(itemScale, itemScale, 1.0))
..translate(-itemExtent / 2, -itemExtent / 2);
context.paintChild(
index,
transform: itemTransform,
opacity: opacity,
);
}
}
@override
bool shouldRepaint(covariant CarouselFlowDelegate oldDelegate) {
return oldDelegate.viewportOffset != viewportOffset;
}
}
@immutable
class FilterItem extends StatelessWidget {
const FilterItem({
super.key,
required this.color,
this.onFilterSelected,
});
final Color color;
final VoidCallback? onFilterSelected;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onFilterSelected,
child: AspectRatio(
aspectRatio: 1.0,
child: Padding(
padding: const EdgeInsets.all(8),
child: ClipOval(
child: Image.network(
'https://docs.flutter.dev/cookbook/img-files/effects/instagram-buttons/millenial-texture.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight,
),
),
),
),
);
}
}